iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Software Development

Android 鐵人賽: 天命最高 - 陪伴大家一步步打造屬於自己的app系列 第 9

Kotlin 協程進階與檔案處理——CoroutineScope、SupervisorJob、async 與 File I/O

  • 分享至 

  • xImage
  •  

上一篇我們認識了 Kotlin Coroutine(協程)的基本概念,知道可以使用 launch 啟動協程、透過 delay() 暫停工作。今天將繼續深入協程,學習多個 CoroutineScopeSupervisorJobasync/await、掛起函式與 Dispatcher 的使用方式,最後再加入 Kotlin 檔案建立、寫入與讀取操作。Kotlin Coroutines Guide


一、今天的學習目標

今天將會學習以下內容:

  1. 使用 CoroutineScope 建立獨立的協程作用域。
  2. 使用 Dispatchers.Default 執行背景運算工作。
  3. 了解 delay()Thread.sleep() 的差異。
  4. 使用 SupervisorJob 隔離不同協程的例外。
  5. 使用 async 建立有回傳值的協程。
  6. 使用 await() 取得 Deferred 的執行結果。
  7. 建立與呼叫 suspend 掛起函式。
  8. 使用 withContext() 切換協程執行環境。
  9. 使用 FileFileWriterFileReader 處理文字檔案。Kotlin Coroutine Basics

第一部分:Kotlin 多工協程

二、什麼是 CoroutineScope?

CoroutineScope 稱為「協程作用域」,主要用途是管理在這個作用域中啟動的協程。當我們使用 launchasync 啟動協程時,該協程會歸屬於特定的作用域。CoroutineScope API

建立協程作用域的基本語法如下:

val scope = CoroutineScope(Dispatchers.Default)

Dispatchers.Default 適合執行需要 CPU 運算的背景工作,例如資料排序、資料轉換或複雜計算。它會使用共享背景執行緒池執行工作。Coroutine Dispatchers


三、使用 launch 啟動第一個協程

下面建立第一個 CoroutineScope,並使用 launch 啟動協程:

var scope1 = CoroutineScope(Dispatchers.Default)

scope1.launch {
    repeat(3) {
        println("第一個 Scope - 協程 :$it")
        delay(300)
    }
}

repeat(3) 會執行三次,變數 it 的值依序為 0、1、2。每次輸出後,使用 delay(300) 將協程暫停 300 毫秒。Kotlin repeat

delay() 只會暫停目前的協程,不會阻塞協程所在的執行緒。因此,在這段協程等待期間,相同執行緒還能繼續執行其他協程工作。Kotlin delay API


四、建立第二個獨立協程作用域

接著建立第二個獨立的協程作用域,並在其中呼叫 sFun()

val job = CoroutineScope(Dispatchers.Default)

job.launch {
    sFun()
}

雖然這個變數命名為 job,但它實際儲存的是 CoroutineScope,不是 Job 物件。為了讓程式語意更加清楚,實務上可以將它改名為 scope2CoroutineScope API

例如:

val scope2 = CoroutineScope(Dispatchers.Default)

scope2.launch {
    sFun()
}

五、掛起函式 suspend fun

sFun() 前面使用 suspend 修飾,代表它是一個「掛起函式」:

suspend fun sFun() {
    for (i in 1..5) {
        println("${(0x41 + i).toChar()}")
        Thread.sleep(500)
    }
}

掛起函式只能在另一個掛起函式或協程內呼叫。它可以在不阻塞整個執行緒的情況下,暫停目前協程並等待工作完成。Composing Suspending Functions

程式中的 0x41 是十六進位整數,對應 Unicode 字元 A。因為迴圈從 1 開始,所以實際輸出的字元如下:

B
C
D
E
F

0x41 + 1 等於 0x42,轉換為字元後就是 B;後面的字元以此類推。Kotlin toChar

不過,掛起函式中不建議使用 Thread.sleep(500),因為它會阻塞目前執行緒。較好的方式是改成 delay(500)

suspend fun sFun() {
    for (i in 1..5) {
        println("${(0x41 + i).toChar()}")

        // 使用 delay() 只暫停目前協程,不會阻塞背景執行緒。
        delay(500)
    }
}

資料來源:Kotlin delay API


六、主執行緒與協程同時運作

主程式中還有一個一般的 for 迴圈:

for (count in 1..10) {
    println("count = $count")
    Thread.sleep(400)
}

這段程式執行於主執行緒。每輸出一次 count,就使用 Thread.sleep(400) 阻塞主執行緒 400 毫秒。Java Thread.sleep

因為前面的協程使用 Dispatchers.Default 在背景執行緒池中工作,所以主執行緒、第一個協程及第二個協程可能交錯輸出:

count = 1
第一個 Scope - 協程 :0
B
第一個 Scope - 協程 :1
count = 2
C
第一個 Scope - 協程 :2
count = 3
D
count = 4
E
count = 5
F
count = 6
count = 7
count = 8
count = 9
count = 10

因為協程由執行緒排程器安排工作,每次執行的時間點可能略有不同,所以實際輸出順序不一定完全相同。Coroutine Context and Dispatchers


七、使用 SupervisorJob 隔離協程例外

接下來重新建立 scope1,並加入 SupervisorJob()

scope1 = CoroutineScope(
    Dispatchers.Default + SupervisorJob()
)

一般情況下,父協程或其中一個子協程發生例外時,可能會取消同一工作階層中的其他協程。SupervisorJob 的作用是隔離子協程的失敗,讓其中一個子協程失敗時,不會自動取消其他同層子協程。SupervisorJob API

第一個工作故意產生例外:

scope1.launch {
    delay(100)
    println("Job 1")
    error("job 1 fail")
}

error("job 1 fail") 會拋出 IllegalStateException,因此執行時會看到錯誤堆疊資訊。Kotlin error

第二個工作則正常執行:

scope1.launch {
    delay(200)
    println("job 2 is ok")
}

即使第一個協程發生例外,因為作用域中使用了 SupervisorJob,第二個協程仍然可以繼續執行並輸出:

Job 1
Exception in thread "DefaultDispatcher-worker-1"
java.lang.IllegalStateException: job 1 fail

job 2 is ok

這就是 SupervisorJob 最重要的功能:某個子工作失敗時,其他獨立工作仍能繼續執行。Kotlin Exception Handling


八、launch 與 async 的差異

launch 適合執行不需要回傳結果的工作,它會回傳一個 Job 物件;async 適合執行需要回傳結果的工作,它會回傳 Deferred<T> 物件。Composing Suspending Functions

建立方式 回傳型別 適合用途
launch Job 執行不需要回傳值的工作
async Deferred<T> 執行需要回傳結果的工作
await() T 等待並取得 async 的結果

資料來源:Kotlin async API


九、使用 async 與 await 取得結果

程式使用 runBlocking 建立一個協程作用域:

runBlocking {
    val value1 = async {
        delay(100)
        println("async job 1")
        100
    }

    val value2 = async {
        delay(200)
        println("async job 2")
        200
    }

    println("waiting 3")
    println("value1 = ${value1.await()}, value2 = ${value2.await()}")
    println("sum = ${value1.await() + value2.await()}")
}

runBlocking 會阻塞目前的執行緒,直到區塊中的所有協程完成,因此常用於一般 Kotlin 主程式、測試程式或教學範例。runBlocking API

第一個 async 等待 100 毫秒後回傳 100;第二個 async 等待 200 毫秒後回傳 200

val value1: Deferred<Int> = async {
    delay(100)
    100
}

val value2: Deferred<Int> = async {
    delay(200)
    200
}

此時 value1value2 並不是整數,而是包裝非同步結果的 Deferred<Int>。必須呼叫 await() 才能等待並取得真正的數字。Deferred API

val result1 = value1.await()
val result2 = value2.await()

println("result1 = $result1")
println("result2 = $result2")
println("sum = ${result1 + result2}")

輸出結果如下:

waiting 3
async job 1
async job 2
value1 = 100, value2 = 200
sum = 300

資料來源:Kotlin await API


十、為什麼不能只呼叫 toString()?

原本的範例使用:

println(
    "value1 = ${value1.toString()}, value2 = ${value2.toString()}"
)

這樣輸出的不是 100200,而可能是:

value1 = DeferredCoroutine{Completed},
value2 = DeferredCoroutine{Completed}

原因是 toString() 只會把 Deferred 物件本身轉成文字,顯示它的狀態;它不會取得協程內部的運算結果。Deferred API

正確做法是使用:

println(
    "value1 = ${value1.await()}, value2 = ${value2.await()}"
)

資料來源:Kotlin await API


十一、使用 withContext 切換 Dispatcher

程式中另外定義了 doEvent()doJob()

suspend fun doEvent() {
    withContext(Dispatchers.Default) {
        println("doEvent")
        doJob()
        delay(300)
        println("event end")
    }
}

withContext() 可以切換協程的執行環境,並等待區塊完成後再回到原來的協程環境。withContext API

doEvent() 使用 Dispatchers.Default,適合一般背景計算;它呼叫的 doJob() 則切換到 Dispatchers.IO

suspend fun doJob() {
    withContext(Dispatchers.IO) {
        println("doJob")
        delay(100)
        println("job finished")
    }
}

Dispatchers.IO 適合檔案讀寫、網路存取及資料庫操作等可能需要等待輸入輸出的工作。Dispatchers.IO API

如果在 main() 中加入以下程式:

runBlocking {
    doEvent()
}

執行順序會是:

doEvent
doJob
job finished
event end

因為 doEvent() 必須等待 doJob() 完成,才能繼續執行後面的 delay(300)println("event end")withContext API


十二、MUTI_Task_2.kt 完整程式碼

以下是本次協程範例的完整程式碼:

import kotlinx.coroutines.*

fun main() {

    // =================================================================
    // 1. 建立第一個獨立的協程作用域(變數名稱為 scope1)
    // =================================================================
    var scope1 = CoroutineScope(Dispatchers.Default)

    // 在 scope1 中啟動第一個協程。
    scope1.launch {
        repeat(3) {
            println("第一個 Scope - 協程 :$it")

            // 暫停目前協程 300 毫秒,不阻塞背景執行緒。
            delay(300)
        }
    }

    // =================================================================
    // 2. 建立第二個獨立的協程作用域
    // =================================================================
    val job = CoroutineScope(Dispatchers.Default)

    job.launch {
        // 呼叫下方定義的掛起函式。
        sFun()
    }

    // =================================================================
    // 3. 主執行緒迴圈
    // =================================================================
    for (count in 1..10) {
        println("count = $count")

        // 阻塞主執行緒 400 毫秒。
        Thread.sleep(400)
    }

    // =================================================================
    // 4. 使用 SupervisorJob 隔離不同子協程的例外
    // =================================================================
    scope1 = CoroutineScope(
        Dispatchers.Default + SupervisorJob()
    )

    // 啟動工作 1,並故意引發例外。
    scope1.launch {
        delay(100)
        println("Job 1")

        // 故意拋出 IllegalStateException。
        error("job 1 fail")
    }

    // 啟動工作 2。
    // 因為使用 SupervisorJob,所以不會被工作 1 的失敗取消。
    scope1.launch {
        delay(200)
        println("job 2 is ok")
    }

    // 暫停主執行緒,等待上面的背景協程執行。
    Thread.sleep(1000)
    println("waiting 2")
    println()

    // =================================================================
    // 5. 使用 runBlocking、async 與 await
    // =================================================================
    runBlocking {

        // async 會回傳 Deferred<Int>。
        val value1 = async {
            delay(100)
            println("async job 1")

            // 此區塊的回傳結果為 100。
            100
        }

        // 第二個 async 同樣回傳 Deferred<Int>。
        val value2 = async {
            delay(200)
            println("async job 2")

            // 此區塊的回傳結果為 200。
            200
        }

        println("waiting 3")

        // 使用 await() 等待並取得真正的運算結果。
        val result1 = value1.await()
        val result2 = value2.await()

        println("value1 = $result1, value2 = $result2")
        println("sum = ${result1 + result2}")
        println()
    }

    println()

    // 暫停主執行緒,確認背景工作有足夠時間完成。
    Thread.sleep(1000)
    println("waiting 3")
}

/**
 * 自訂掛起函式。
 *
 * 使用 suspend 修飾後,只能從協程或其他掛起函式中呼叫。
 */
suspend fun sFun() {
    for (i in 1..5) {

        // 0x41 是字元 A。
        // 加上 1~5 後,依序輸出 B、C、D、E、F。
        println("${(0x41 + i).toChar()}")

        // 建議在掛起函式中使用 delay(),
        // 避免 Thread.sleep() 阻塞整個背景執行緒。
        delay(500)
    }
}

/**
 * 示範使用 Dispatchers.Default 執行一般背景工作。
 */
suspend fun doEvent() {
    withContext(Dispatchers.Default) {
        println("doEvent")

        // 呼叫另一個掛起函式,並等待它執行完成。
        doJob()

        delay(300)
        println("event end")
    }
}

/**
 * 示範使用 Dispatchers.IO 執行輸入輸出工作。
 */
suspend fun doJob() {
    withContext(Dispatchers.IO) {
        println("doJob")
        delay(100)
        println("job finished")
    }
}

資料來源:kotlinx.coroutines API


第二部分:Kotlin 檔案處理

十三、取得目前執行路徑

Kotlin 可以使用 Java 的 File 類別處理檔案與資料夾:

val dir = File("").absolutePath
println("current path: $dir")

File("") 代表目前的工作目錄,absolutePath 會取得它的絕對路徑。Java File API


十四、建立資料夾

以下程式會在 Windows 的 C:\ 建立 kotlin 資料夾:

val newDir = File("c:\\kotlin")
val result = newDir.mkdir()

println("result = $result")

因為 Kotlin 字串中的反斜線 \ 是跳脫字元,所以 Windows 路徑必須寫成 c:\\kotlinmkdir() 成功建立資料夾時回傳 true;如果資料夾已存在或建立失敗,則回傳 falseJava File.mkdir

也可以使用原始字串,讓 Windows 路徑更容易閱讀:

val newDir = File("""C:\kotlin""")

Kotlin 的三個雙引號可以建立原始字串,裡面的反斜線不需要再次跳脫。Kotlin Strings


十五、建立與寫入文字檔案

使用 File.apply 可以在建立物件後,連續執行多個檔案操作:

val file = File("c:\\kotlin\\data1.txt").apply {
    createNewFile()
    writeText("Hello Mary \n")
    appendText("Good morning !\n")
    appendText("This is kotlin class. \n")
}

各個函式的用途如下:

函式 用途
createNewFile() 建立新檔案
writeText() 寫入文字,原有內容會被覆蓋
appendText() 將文字附加在檔案末端
readText() 一次讀取整個文字檔案
readLines() 將每一行讀成字串串列

資料來源:Kotlin File Extensions


十六、一次讀取整個檔案

使用 readText() 可以一次取得檔案內的全部文字:

val data = file.readText()
println(data)

輸出如下:

Hello Mary
Good morning !
This is kotlin class.

readText() 適合讀取容量較小的文字檔案。如果檔案非常大,應改用緩衝串流或逐行讀取,以免一次占用大量記憶體。Kotlin readText


十七、逐行讀取檔案

使用 readLines() 可以將檔案內容轉成 List<String>

val lines = file.readLines()

lines.forEach { line ->
    println("data = $line")
}

輸出如下:

data = Hello Mary
data = Good morning !
data = This is kotlin class.

資料來源:Kotlin readLines


十八、使用 FileWriter 寫入檔案

除了 Kotlin 提供的 writeText(),也可以使用 Java 的 FileWriter

val newFile = FileWriter(
    "c:\\kotlin\\data2.txt",
    false
)

第二個參數表示是否採用附加模式:

參數 寫入方式
false 覆蓋原有內容
true 將內容附加在檔案末端

資料來源:Java FileWriter API


十九、使用 use 自動關閉檔案

檔案寫入完成後必須關閉 FileWriter,否則可能造成資料尚未完整寫入,或檔案持續被程式鎖定。Kotlin 可以使用 use 自動關閉資源:

newFile.use {
    it.write(0x31)
    it.write(0x38)
    it.write(0x41)
    it.write("\n")

    val data = charArrayOf(
        'H', 'e', 'l', 'l', 'o', '\n'
    )

    it.write(data)
    it.write("It is sunny day\n")
}

use 的概念與 Java 的 try-with-resources 類似。不論區塊正常完成或發生例外,都會自動呼叫資源的 close()Kotlin use

其中:

it.write(0x31)
it.write(0x38)
it.write(0x41)

寫入的是 Unicode 字元值:

十六進位 十進位 字元
0x31 49 1
0x38 56 8
0x41 65 A

資料來源:Unicode Latin Basic

因此,data2.txt 的內容會是:

18A
Hello
It is sunny day

二十、使用 FileReader 與字元緩衝區

下面建立長度為 50 的 CharArray,作為檔案讀取緩衝區:

val chBuff = CharArray(50)
var len = 0
var myFile = FileReader("c:\\kotlin\\data2.txt")

接著使用 read(chBuff) 把文字讀入字元陣列:

myFile.use {
    len = it.read(chBuff)
    println("len = $len")
}

read() 會回傳實際讀到的字元數。如果已經到達檔案結尾,則會回傳 -1Java Reader.read


二十一、將字元陣列轉成 String

讀取完成後,可以將緩衝區中的有效範圍轉成字串:

println(String(chBuff, 0, len))

三個參數分別代表:

  1. chBuff:來源字元陣列。
  2. 0:開始位置。
  3. len:要轉換的字元數量。

資料來源:Java String Constructor

也可以使用 for 迴圈逐字輸出:

for (i in 0 until len) {
    print(chBuff[i])
}

println()

0 until len 會建立從 0len - 1 的整數範圍,可避免存取到字元陣列有效內容以外的位置。Kotlin Ranges


二十二、FileControl.kt 完整程式碼

import java.io.File
import java.io.FileReader
import java.io.FileWriter

fun main() {

    // ================================================================
    // 1. 取得並輸出目前專案的執行路徑(工作目錄)
    // ================================================================
    val dir = File("").absolutePath
    println("current path: $dir")

    // ================================================================
    // 2. 在 Windows 的 C 槽建立 kotlin 資料夾
    // ================================================================
    val newDir = File("c:\\kotlin")

    // mkdir() 成功建立資料夾時回傳 true。
    // 若資料夾已存在或建立失敗,則回傳 false。
    val result = newDir.mkdir()
    println("result = $result")

    // ================================================================
    // 3. 使用 Kotlin 擴充函式操作 data1.txt
    // ================================================================
    val file = File("c:\\kotlin\\data1.txt").apply {

        // 建立新檔案。
        createNewFile()

        // 寫入文字;如果檔案原本有內容,會覆蓋舊內容。
        writeText("Hello Mary \n")

        // 將文字附加到檔案末端。
        appendText("Good morning !\n")

        // 再次附加一行文字。
        appendText("This is kotlin class. \n")
    }

    println()

    // ================================================================
    // 4. 使用 readText() 一次讀取完整內容
    // ================================================================
    val data = file.readText()
    println(data)

    println()

    // ================================================================
    // 5. 使用 readLines() 按行讀取內容
    // ================================================================
    val lines = file.readLines()

    lines.forEach { line ->
        println("data = $line")
    }

    println()

    // ================================================================
    // 6. 使用 FileWriter 寫入 data2.txt
    // ================================================================

    // FileWriter 的第二個參數代表附加模式:
    // false:覆蓋寫入。
    // true:附加寫入。
    val newFile = FileWriter(
        "c:\\kotlin\\data2.txt",
        false
    )

    // use 相當於 Java 的 try-with-resources。
    // 區塊結束後會自動關閉 FileWriter,釋放檔案資源。
    newFile.use { writer ->

        // write(Int) 會將數值當成 Unicode 字元碼寫入。
        writer.write(0x31) // 字元 1
        writer.write(0x38) // 字元 8
        writer.write(0x41) // 字元 A
        writer.write("\n") // 換行

        // 建立字元陣列。
        val charData = charArrayOf(
            'H',
            'e',
            'l',
            'l',
            'o',
            '\n'
        )

        // 寫入完整字元陣列。
        writer.write(charData)

        // 直接寫入字串。
        writer.write("It is sunny day\n")
    }

    // ================================================================
    // 7. 使用 readText() 快速讀取 data2.txt
    // ================================================================
    val readData = File(
        "c:\\kotlin\\data2.txt"
    ).readText()

    println(readData)

    // ================================================================
    // 8. 使用 FileReader 與字元陣列讀取檔案
    // ================================================================

    // 建立可存放 50 個字元的緩衝區。
    val chBuff = CharArray(50)

    // 記錄實際讀取到的字元數量。
    var len = 0

    var myFile = FileReader(
        "c:\\kotlin\\data2.txt"
    )

    myFile.use { reader ->

        // 將資料讀入 chBuff。
        // 回傳值是實際讀取到的字元數量。
        len = reader.read(chBuff)
        println("len = $len")
    }

    // ================================================================
    // 9. 輸出字元陣列的有效內容
    // ================================================================

    // 方法 A:
    // 從索引 0 開始,將 len 個字元轉換為 String。
    if (len > 0) {
        println(String(chBuff, 0, len))
    }

    println()

    // 方法 B:
    // 使用迴圈逐字輸出。
    if (len > 0) {
        for (i in 0 until len) {
            print(chBuff[i])
        }
    }

    println()

    // ================================================================
    // 10. 使用 FileReader 的 readLines() 按行讀取
    // ================================================================
    myFile = FileReader(
        "c:\\kotlin\\data2.txt"
    )

    myFile.use { reader ->

        // readLines() 是 Kotlin 為 Reader 提供的擴充函式。
        val dataList = reader.readLines()

        for (lineData in dataList) {
            println(lineData)
        }
    }
}

資料來源:Kotlin I/O API


二十三、協程搭配檔案讀寫

當檔案內容比較大,或檔案位於速度較慢的儲存裝置時,讀寫操作不應放在主執行緒執行。可以使用 withContext(Dispatchers.IO) 將檔案操作切換到 I/O 執行緒池。Dispatchers.IO API

以下是協程搭配檔案寫入的完整範例:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.io.File

fun main() = runBlocking {

    // 呼叫掛起函式並等待檔案寫入完成。
    saveData()

    // 呼叫掛起函式並取得檔案內容。
    val content = readData()

    println("檔案內容:")
    println(content)
}

/**
 * 使用 Dispatchers.IO 寫入檔案。
 */
suspend fun saveData() {
    withContext(Dispatchers.IO) {

        val directory = File("c:\\kotlin")

        // 如果資料夾不存在,就建立資料夾及必要的上層目錄。
        if (!directory.exists()) {
            directory.mkdirs()
        }

        val file = File(
            directory,
            "coroutine_data.txt"
        )

        // writeText() 會覆蓋原本的檔案內容。
        file.writeText(
            "Kotlin Coroutine\n" +
                "File I/O with Dispatchers.IO\n"
        )

        println("檔案寫入完成:${file.absolutePath}")
    }
}

/**
 * 使用 Dispatchers.IO 讀取檔案。
 */
suspend fun readData(): String {
    return withContext(Dispatchers.IO) {

        val file = File(
            "c:\\kotlin\\coroutine_data.txt"
        )

        // 先判斷檔案是否存在,避免直接讀取不存在的檔案。
        if (file.exists()) {
            file.readText()
        } else {
            "找不到檔案:${file.absolutePath}"
        }
    }
}

資料來源:Coroutine Context and Dispatchers


二十四、今天的重點整理

今天的範例同時涵蓋 Kotlin 協程與檔案操作。CoroutineScope 用來管理協程生命週期;launch 適合沒有回傳值的工作;async 適合需要回傳結果的工作,而結果必須透過 await() 取得。Kotlin Coroutines Guide

SupervisorJob 可以避免單一子協程失敗時取消其他同層協程,適合多個彼此獨立的背景工作。例如 App 同時更新使用者資料、載入圖片與取得通知時,其中一個工作失敗,不一定要讓其他工作全部停止。SupervisorJob API

在掛起函式中,等待時間應優先使用 delay(),避免使用會阻塞執行緒的 Thread.sleep()。如果需要處理檔案、網路或資料庫,則可以透過 withContext(Dispatchers.IO) 切換到適合 I/O 工作的執行緒池。Kotlin delay API

檔案處理方面,Kotlin 提供 writeText()appendText()readText()readLines() 等簡潔的擴充函式;若需要更細部地控制讀寫,也可以使用 FileWriterFileReader 和字元緩衝區。Kotlin File API

透過今天的練習,我們已經能夠同時執行多個背景工作、隔離協程例外、取得非同步運算結果,並將資料寫入檔案。下一步就可以把這些技術運用在 Android App,例如背景下載、API 資料取得、Room 資料庫操作,以及本機檔案儲存。Android Kotlin Coroutines


上一篇
Kotlin Coroutine 入門:協程、CoroutineScope、SupervisorJob 與 async 非同步程式設計
下一篇
Android 畫面設計入門——ConstraintLayout、View Binding 與 Fragment 切換
系列文
Android 鐵人賽: 天命最高 - 陪伴大家一步步打造屬於自己的app13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言